feat(account): read-only mode for abuse-paused accounts - #1049
Merged
Merged
Conversation
An account whose sending is paused with pause class `abuse` is now read-only. One enforcement point per surface: - /v1: a Huma middleware (httpapi/read_only.go) classifies every operation (method rule; exceptions: validateTemplate reads, deleteAccount trash stays allowed, getInfo public) and refuses writes with 403 account_read_only. Uncached PK lookup per write; a failed lookup fails closed with 503. Covers every credential kind, since all resolve to a principal owned by the account. - legacy mux: a gorilla middleware (agent/read_only.go) for the dashboard account routes and OAuth consent; the HITL magic links and the internal external-principal attach check in their handlers. - GET /v1/account reports read_only; the operator pause readback prints read_only. - notifications.support_email (falls back to reply_to) names the support contact in the message; the reason text is never shown. Spec-walk test classifies every operation against an independent derivation and drives each one over HTTP as a read-only account. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
- regenerate both SDK bases (ErrorBody vocabulary, AccountView.read_only) - TS + Python map account_read_only to the permission family, never retryable - CLI: new frozen exit code 10 (READ_ONLY) with guidance in the top-level error rendering, and whoami prints the read-only state - MCP: per-tool mutating classification (tools/mutating.ts) pinned against the registered tools and the annotations; the /v1 guard stays the enforcement point, and a refused tool call surfaces [account_read_only] with structured code; whoami explains read_only Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…unts
A persistent, non-dismissible banner in the app shell ("Your account is
read-only while sending is paused for abuse review. Contact support.")
driven by GET /v1/account read_only through the shared limits SWR entry.
The request helper turns a 403 account_read_only into that copy; the
Create key and Add domain controls are disabled and Create inbox is
hidden while read-only. The API error remains the backstop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The contract server seeds an abuse-paused account
(E2A_TEST_READONLY_API_KEY / {readonly_api_key}). The shared scenario
asserts read_only:true and reads return 200, one representative write
per resource family (agents, messages, domains, api keys, webhooks,
contacts, templates, suppressions, sending access) returns 403
account_read_only without the reason text, the permanent erase stays
409 erase_held, and the account trash still returns 200 mode=trash.
Wired through the Go, TypeScript and Python runners.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…ling Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The legacy guard resolved the caller with authenticatePrincipal (Authorization header first, cookie only as fallback) and fell through on auth errors, while every legacyAccountWrite handler authenticates the session cookie only. A read-only session plus a junk bearer, or another account's valid key, bypassed the guard: key create, agent rename/delete, profile PATCH, consent. Session-only routes now resolve the caller exactly as the handlers do and check read-only for that user; an Authorization header on such a route is 400 ambiguous_credentials, and a missing session is 401 from the guard. Unclassified write routes default to refuse: every presented credential must resolve and none may be read-only (anonymous requests, e.g. the SNS webhook mounted on the same router, still pass). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The TTL sweep expire-approved holds with no read-only check, releasing an abuse-paused account's suspicious inbound mail into the inbox and webhooks (review_expired_approved) and resolving its held outbound mail. Approve-on- expiry holds of a read-only account are now excluded from both candidate queries, so they stay pending_review without sitting at the head of the ordered, limited sweep and starving other accounts. Reject-on-expiry holds still resolve; a resume makes the holds candidates again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…ccount While an account is abuse-paused, moving it to the trash no longer runs the per-domain sender-identity teardown, so the provider-side identities survive as evidence for the review. The domains are still unverified (nothing is sent from them) and the purge at the end of the trash window tears them down. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
readOnlyGuard dereferenced p.User.ID unguarded; a resolver returning neither an error nor an account now gets 503 auth_unavailable instead of a panic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
… window Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…meta Nothing enforced the per-tool mutating classification beyond readOnlyHint agreement. TOOL_OPERATIONS now records the /v1 operation(s) each tool calls; a test walks it against api/openapi.yaml and requires mutating exactly when one of them is a write under the server's rule. Every destructiveHint tool must be mutating, and each tool advertises the flag as _meta["e2a/mutating"]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Raw chi routes on the /v1 root bypass the Huma readOnlyGuard. A chi.Walk test now fails on any non-GET route that is neither a Huma operation nor on an explicit exemption list carrying its reason (unsubscribe, magic links, trash interstitial). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…y account The internal attach refused every call for an abuse-paused account, so the reconciler's replays of already-made attaches started failing. The check now runs in the store's attach transaction and only for a NEW mapping: a replay of an attached (issuer, subject) -> user triple returns 200 as before; a new principal for a read-only account stays 403 account_read_only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
… checks The legacy guard refused consent up front, which swallowed deny (the client never got fosite's access_denied redirect) and replaced consent's own 404/503/authorize errors with a guard 401 for unauthenticated callers. The read-only check now runs in handleOAuthConsent after the provider, authorize request and session checks, for allow only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…ndler The legacy session-write guard and the handler behind it each looked up the session. The guard now passes the user it checked (auth.WithSessionUser), so there is one lookup and the handler acts for exactly the checked user. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…limit order Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An account whose sending is paused with
pause_class = 'abuse'(columns from migration 122) is now read-only on every customer surface: no write of any kind succeeds. Pauses with the other classes (operator,billing,system) keep today's behaviour (sending refused, everything else works), because those are the automated detector's lever and may hit legitimate customers.Motivation. The 2026-09 phishing operator, while paused, could still create brand-named agents, rename them, delete messages and register domains; only sending and permanent deletion were blocked.
Rule. State is exactly
state='paused' AND pause_class='abuse'(identity.Store.AccountReadOnly); a resume lifts it immediately. Enforced at one guard per surface:internal/httpapi/read_only.go(/v1),internal/agent/read_only.go(legacy dashboard mux),mcp/src/tools/mutating.ts(per-tool flag), WebSocket by message type; agent access tokens and delegated tokens are refused for writes like any other credential. Fails closed on DB errors (503, no write).Refused (403
account_read_only): agent create/update/rename/restore/trash; message send/reply/forward/schedule; review approve/reject; message delete/restore/label edits; domain register/verify/delete; API key create/delete; webhook create/update/delete/rotate/test; event redelivery; contacts + imports; templates; outreach upserts/deletes; protection settings; suppression create/delete; sending-access request; legacy dashboard writes; OAuth consentallow(mints a grant; checked in the handler after consent's own provider/authorize/session handling); HITL magic-link approve/reject; attaching a NEW external principal (403 account_read_onlyfromPOST /api/internal/users/external-principals/attach— TC side: a reconciler replay of an already-attached (issuer, subject) → user triple still returns200; only a new principal for a paused account gets the new 403). The legacy account-write routes authenticate the session cookie only, and the guard resolves the caller the same way: anAuthorizationheader on one of them is400 ambiguous_credentialsfor everyone, and no valid session is401from the guard. A legacy write route missing from the classification table defaults to refuse. The HITL expiry sweep leaves a read-only account's approve-on-expiry holdspending_review(nothing released into the inbox/webhooks, nothing sent); reject-on-expiry holds still resolve.Allowed: every read (incl. exports, metrics, events, attachment downloads, WS live-tail);
POST /v1/templates/validate; sign-in/out, OAuth token exchange/revocation,/agent/identity; OAuth consentdeny(fosite'saccess_deniedredirect still reaches the client); account trash (DELETE /v1/accountwithoutpermanent→mode: "trash"); the restore/erase interstitial (erase stays409 erase_held). Operator local commands unaffected.Decision: SES sender identities survive the trash of a read-only account. Trashing an account normally enqueues the SES sender-identity teardown for each owned domain. While the account is read-only (abuse pause) the trash skips that teardown: the provider-side identities are evidence for the abuse review, and deleting them at trash time would destroy it. The domains are still unverified, so nothing is sent from them. The purge at the end of the trash window is not held by a pause and still tears the identities down, so nothing is kept forever. Other pause classes and unpaused accounts are unchanged.
MCP: every tool advertises
_meta["e2a/mutating"]; the classification is pinned to the HTTP methods of the/v1operations each tool calls (walked againstapi/openapi.yaml) and every destructiveHint tool must be mutating. Raw (non-Huma) routes on the/v1chi root are walked too: each non-GET one is a Huma operation or on an explicit exemption list.Rate limiting:
/v1rate limiting runs before the read-only guard, so a refused write still counts against the request budget. Kept deliberately — running the guard's DB lookup first would let an over-the-limit caller drive unlimited lookups.Clients: new vocabulary code
account_read_onlyin openapi, both generated SDK models, TS/Python error mapping, CLI guidance (distinct exit code), MCP tool error text, web copy; a persistent dashboard banner plus disabled write controls (web/src/app/components/ReadOnlyBanner.tsx). Contract server seeds an abuse-paused account; conformance scenario: one representative write per resource family → 403, reads → 200, trash delete → 200.Tests: spec-walk test classifies every openapi operation as read or write explicitly — an unclassified route fails the build; store/guard tests with real Postgres;
-raceon touched packages. Docs:docs/design/account-read-only.md,docs/api.md,docs/data-handling.md,docs/deployment.md.Accepted window: the guards read the control row outside the handler transaction, so a write whose check passed milliseconds before a pause commits can complete; sending is re-checked at the sending gate. Documented in the design doc's Freshness section.
Operator note:
-pause-account-sending -pause-class abusenow also freezes writes;-resume-account-sendinglifts it.Implementation by a Claude Code subagent; the final push and this PR were made by the main session after the subagent's connection dropped twice post-implementation. Review rounds to follow before merge.
🤖 Generated with Claude Code
https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW